Reach a person's own Gmail, Slack or Linear through Composio - #481
Draft
mxmzb wants to merge 238 commits into
Draft
Reach a person's own Gmail, Slack or Linear through Composio#481mxmzb wants to merge 238 commits into
mxmzb wants to merge 238 commits into
Conversation
mxmzb
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso and
tylerslaton
as code owners
September 10, 2026 13:33
mxmzb
marked this pull request as draft
September 10, 2026 13:53
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…sted Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
The plan's code blocks were written before being run through the formatter, and the executor was told three times to copy them verbatim — so it correctly chose verbatim over formatted and reported the deviation rather than silently fixing it. Whitespace only: six wrap sites across the two files, no content change, and the suite is the same 24 passing tests either side of this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
… connections Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…he real thing Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…places Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…and check the new columns round-trip Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…ture ids
The Composio fixtures at the bottom of this file insert at `gmail`, `notion`,
`bot_helper` and `user_asker`, and none of those ids is a choice:
`seedNotionServer` needs `catalogueEntry("notion")` to resolve to the real
catalogue entry, and `gmail` is the toolkit slug that gets sent to the vendor.
`freshDatabase` deleted them unconditionally to make room, which against a
database somebody is using is a lot to take.
`mcp_user_credentials` references `mcp_servers.id`, so removing a real `notion`
row takes every person's per-user credential row with it and leaves their
encrypted vault rows referenced by nothing — unreachable from any screen and
invisible to `retireConnectionsFor`, which exists to stop exactly that state.
Removing a real Bot takes six tables: its channel memberships, its agent
profile, everyone's preferences for it, its routines and all of their run
history, its component exclusions and its plugin grants. The fixtures then
re-insert byte-identical look-alikes, so nothing on screen would say it
happened.
The `*WasAlreadyConfigured` pattern the older suites use cannot help here: they
only read those rows, so skipping a delete is enough for them, while a fixture
that inserts at an id cannot coexist with a real row there at all — skipping the
delete would just turn the disaster into a primary-key conflict, and
capture-and-restore restores after the cascade has already run. So this refuses
to run instead: a file-level guard looks for the rows the suite intends to own
and throws naming what it found and where to point DATABASE_URL. That is what
makes the deletes safe, and the comment now says so.
Two more things while in here. There was no file-level `afterAll`, so the last
test's fixtures were permanent — one run left behind a `notion` server row and a
`notion-fetch` action nobody configured, which makes that database advertise a
connector nobody set up, and on the next run the rotation suites read the leak
as the deployment's own row and correctly declined to clean it. And the
connection delete matched on the toolkit alone, which is every person's Gmail
connection rather than the fixture's; it now names the two people this file
invents.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
`serverWasAlreadyConfigured` and `toolWasAlreadyAdvertised` were captured in a file-level `beforeAll` and read by the file-level `afterAll`, which deleted the `google-drive` server row and its `search_files` tool row when they were `false`. Both were initialised to `false`, so the value that authorised the delete was also the value they held before anything had looked. A flag meaning "delete this" must not default to the value that authorises deletion, because a setup that aborts leaves every flag sitting at its default. The guard added alongside them is what made that path routine rather than exceptional. It is a file-level `beforeAll` ahead of the capturing one, and it throws by design whenever the database already holds the fixture ids this suite inserts at — a documented outcome rather than a crash. bun runs `afterAll` anyway, and the capturing hook never got to run, so the teardown concluded it had created rows it had never looked at and removed them. Verified against a database holding an operator's own `google-drive` connector and its advertised `search_files` tool, plus a `gmail` row to trip the guard: the run refused as designed, `gmail` survived, and the two rows the suite had no business touching were gone. The cascade off `mcp_servers.id` then takes every person's per-user credential row and strands their encrypted vault rows behind a dangling reference, invisible to `retireConnectionsFor` — the exact harm the guard's own comment cites as its reason for existing. So the flags now count creations. `suiteCreatedServerRow` and `suiteCreatedToolRow` are set to `true` only where the capture actually ran and found the row absent, and the teardown deletes on `true`. `false` covers both "the deployment already had it" and "nobody ever looked", which is the right answer for both: neither is this suite's row to remove. An early throw leaves both at their initialisers and nothing is deleted. Gating that `afterAll` on `ownsFixtureIds` would have worked today and answers a different ownership question — which ids the guard cleared, not which rows this run wrote — and would break silently if either hook moved. The two describe-scoped `notionWasAlreadyConfigured` teardowns keep the inverted shape; a file-level `beforeAll` that throws stops describe-scoped hooks from running at all, so that path cannot reach them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…ial behind it `reachedAs` was derived from the collapsed `CredentialSource`, and `CREDENTIAL_BY_AUTH` maps both `none` and `builtin` to `"none"` — so by the time the answer was chosen, the two kinds were indistinguishable. They do not share an answer. A credential-free endpoint is public: it touches nobody's account and answers every person identically, so naming the asker asserts a per-person attribution that does not exist. The builtin one has no credential for the opposite reason — the call runs against this deployment's own tables as the person whose turn it is, so the person is exactly who it reached. Collapsing both to one credential source erased that distinction at precisely the point it mattered, and gave `person` to a public endpoint. `REACHED_AS_BY_AUTH` is keyed on the auth kind instead, which is the field the answer actually depends on, and being a `Record` over that union it makes the compiler ask the question for any auth kind added later. That is what the module already claimed and could not deliver: the existing `Record` only forced a new kind to declare a credential source, and `reachedAs` fell out of that, so the first `none` entry would have got no compile error, no failing test, and a wrong audit row. No catalogue slug uses `none` today, so nothing changes at runtime — Drive and Notion are `user-oauth`, Routines is `builtin`, and all three still resolve to the person. The new test constructs a `none` entry directly so the file's stated exhaustiveness property holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…om an entry `transportFor` took a catalogue entry and read `entry?.transport ?? "mcp"` off it. That was complete while every server either had a frozen entry or was somebody's MCP endpoint. A Composio app is neither — no entry, so the fallback answered MCP and `composio://gmail` would have been dialled as an HTTP server. `accessFor` already resolves the kind once for every row shape. Both call sites in the store now read it off `access` and pass the kind, so this file only does the lookup. The Drive test asserted the same fallback through `transportFor(null)`. It now composes through `accessFor`, which is where the absent-entry decision moved; the property it checks is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
Three of the four stated that the catalogue entry decides the protocol, which is precisely the defect this branch removed: a Composio app has no entry, so deriving the transport from one dialled `composio://gmail` as an HTTP MCP server. A reader reconstructing the rule from those comments reconstructs the bug. `accessFor` decides now, once, for every row shape, and the comments say so. The fourth comment was not wrong, only orphaned: it was written about `entry`, and a later commit inserted the `access` block between it and the return, so it had come to read as a preamble to something that is never null. Moved back above the line it describes, unchanged. The listing path now has a test. It asserts that a Composio row reaches the Composio client, and it goes red when `refreshTools` resolves the transport from the entry instead — verified by making that change and watching it fail. The calling path cannot be covered until a version argument is threaded through, so it is left for later. A Drive test's negative assertion became a positive one. Asserting "not the Drive adapter" for an entry-less server would have been satisfied by any wrongly resolved transport, including the very defect under repair; it now asserts the MCP adapter by identity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
The phrase substitution landed without rewrapping the paragraph around it, so a commit whose whole purpose was making four comments readable left one of them the widest line in its file by twenty-nine columns, wrapping mid-link in any ordinary editor. Biome does not reflow prose, so no gate caught it. Whitespace only: the sentence is unchanged and the paragraph now wraps at the same width as the two above it.
…has not connected A brokered app holds one deployment key and relies on Composio to keep people's accounts apart, so the only thing that separates them is the person id the call runs under. Two states therefore have to be refused before anything is spent at the vendor: a run attributed to nobody, and a person who has not connected that app at all. Both are refused in `connectionTokenFor`, beside the `user-oauth` refusals they are modelled on, for the same two reasons — the person gets a sentence naming the step they can take, and no call is spent finding out. The transport refuses again as a last line. `connectionTokenFor` now takes the `ServerAccess` descriptor its callers already hold, and the per-person branch reads `access.credential` rather than the catalogue entry's auth kind, so both branches decide from one source instead of two that can disagree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…e's mailbox The brokered branch of `connectionTokenFor` refuses two things: a run that is not attributed to anybody, and an asker who has not connected the app. Both shipped with nothing in the suite exercising them. A throw planted as the first statement inside the `if (access.credential === "brokered")` branch left the whole suite green at 1883 pass — nothing entered that arm, so either refusal could have been deleted without a single test noticing. Silently deletable is the worst state for a security refusal to be in. Two tests now enter it, and each goes red when its own refusal is deleted. Removing the `if (!actorId)` block makes the unattributed test fail on the wrong refusal reaching it; removing the `if (!connected)` block makes the not- connected test fail because the call resolves and the stub records a slug, which is a call spent at the broker to find out what the row already knew. Both assert that the vendor stub was never reached, so the refusal is proven to happen before a call is spent rather than merely somewhere. The docblock above the function had stopped enumerating the branches it governs while the code appealed to it as authority. It described a two-case function keyed on auth kinds the body no longer reads, said nothing about the brokered arm that carries both refusals, and stated unconditionally that a refresh token is exchanged per call — telling a reader on the brokered path that something is exchanged where no refresh token exists at all. It now names all four kinds, and says which paths have nothing to cache versus the one where not caching is a decision. `row.url` went with it. It was read nowhere in the function, and dropping it fits the parameter back onto one line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
Both call sites in the plugin store still called classifyTool with three arguments, so every Composio action was classified by the catalogue's hand-written write list. A Composio app has no such list and no catalogue entry at all, so every action read as a write regardless of the effect the vendor recorded when the action was listed. callTool now selects the recorded effect alongside the input schema and passes it; listServers already selected every column, so its tool map only needed the argument. destructive and version are selected in callTool but not yet read: version is consumed by a later change and destructive by the confirmation card, and selecting them now keeps that a one-line diff rather than a re-shaped query. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…l path The recorded effect was already being selected and passed at both call sites, but nothing asserted the result at either one. The call path's audit row and the admin page's listing both depended on it and neither would have said so if the argument went away. The admin-page site had no coverage at all, and it needed nothing deferred to test it: listServers reads the seeded row directly, so a plain assertion on the returned tool's effect is enough. Each test now fails only when its own call site regresses. Reverting the callTool call to three arguments fails the audit-row test alone; reverting the listServers call fails the listing test alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
The block claimed a server with no catalogue entry behind it was a write throughout, because nothing reviewed said any tool of theirs only reads. A test in this same branch asserts the opposite: composio-classify.test.ts takes a recorded `read` on a null entry and expects a read, as does the store integration test just added for the brokered call path. It also described the advertised-and-absent-from-the-write-list case as the only way to produce a read, and said so as "it is the only one". A second read-producing case has existed unmentioned since the recorded effect was introduced: an advertised action whose recorded effect is exactly `read`, which is a read regardless of the write list and regardless of whether an entry exists. The doc now names both sources, the order they are consulted in, and both ways a read can be earned. The reconciliation helper's justification inherited the same stale premise. Its conclusion is still right — an entry-less server is not reconciled — but the reason is no longer that all of its tools are already writes. It is that a brokered app is classified from the vendor's per-action label rather than from a hand-written list, so there is no under-inclusion here to find. Comments only; no behaviour changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…l a missing mode from an empty one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
… suite a DOM early enough to see it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…or every app that has one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
… say nothing where there is none Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…connection paths now take `confirmBrokeredConnection` inserted on the column defaults, so every consent connection made since migration 0030 read `verified: false` beside a null `verified_at` — byte-identical to a key somebody typed in that nobody ever checked — and only the rows the backfill touched told the truth. The upsert moves into `recordBrokeredConnection`, which spells `verified` and `verifiedAt` together and leaves `connected_at` alone, and the confirm calls it with `verified: true`: a consent screen is a verification by construction, since the person authenticated at the vendor and Composio answered that the account is live. One writer, so the verified and unverified paths cannot drift into two row shapes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…nd what it asks for Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…d forget a check when its account goes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…t on a key app will actually ask for Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…r its values Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…ail it never does Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…'s reason in the form that asked for it `verified` stopped meaning a probe the moment the confirm began writing it true off a consent screen and migration 0030 backfilled every consent row that came before, so the button gated on it alone was drawn on every connected Gmail and could only fail. It is gated on the kind as well, and the flag's own doc no longer claims a call was spent. The sentence for an unverified key asserted a property of the app — that it publishes nothing checkable — while the store writes `false` on every key connection it makes. It now says only what is true of all three states behind that word, and the reason waits for the verification probe to send one. A key Composio refused said so in the vendor's words, on a banner behind the dialog's own backdrop. The refusal is rendered in the form too, under the button that was pressed. And an administrator gets back the line the row replaced: the half saying setup is complete without them is passed separately and kept by the kind that does not leave. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…the account a row test builds Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
… states the answer left Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…er the app Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…lished Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…ndor rejects it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…es live at the app Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…ck where it is bad Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…lly the kinds so a catalogue gone flat reads as one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…r no longer offers Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…urvive a reload Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…used one still says so after a reload Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…so a key app still connects without one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…ame one the check never would, and refuse a re-check where there is no key Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…eck never attempts An unverified row against an app that has a probe used to have one producer: a connect whose probe failed and whose withdrawal then failed too. A refused re-check now writes the same pair, over an account it deliberately leaves alone — the account predates the press and is the person's own, so taking it away to report a bad key would destroy the thing they came to repair. So the row said the deployment could not withdraw the account, which is true on one path and false on the other. It now states the refusal and the standing account without naming a cause, and the read's own argument records both producers and that which of the two is not recoverable from the pair. The comment above the last branch said it stood for every page load. The connections read carries the probe now, so what is left there is the absence of any answer at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…ody tried Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…hat leave the account standing Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…othing spent cannot withhold the only press that could spend one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…ame both fields the read now carries Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
Composio becomes a fourth way a connector is reached, alongside MCP, the Google Drive REST adapter, and Routines.
An app an operator enables is an ordinary
mcp_serversrow markedprovenance = "composio"with urlcomposio://<toolkit>. A new transport dials it, and each call runs in the asking person's own account: the deployment holds one key for the broker, and which person's Gmail or Slack it opens is decided entirely by the user id sent alongside that key — taken from their authenticated session, never from anything a model supplied.Everything downstream is untouched. The permission check, the rule engine and the audit row work exactly as they do for Drive and Notion.
Read this first: steps 1 and 2 only, deliberately
Nothing in the shipped product installs a Composio client, reads
COMPOSIO_API_KEY, enables an app, or creates a connection row. That is intended for this change and it has a visible consequence: the "you have not connected this app" refusal is reachable, while the thing it tells you to go and do does not exist yet.Concretely, so nobody has to discover it:
useComposioClienthas no caller underserver/src, so on any real deployment the client is null and every Composio path answers empty or refuses.@composio/coreis therefore indevDependencies, notdependencies— only the opt-in live test imports it. Step 3 must move that one line back and re-runbun installthe moment a file underserver/srcimports the SDK. Watch for that import; it is the trigger.COMPOSIO_API_KEYentry was added to.env.example. An entry there is a promise the running product reads the variable, and nothing does..env.exampleis byte-identical to main.docker-compose.yml,charts/openbot/templates/_helpers.tplanddocs/configuration.mdall need the key forwarding, and@composio/clientresolves to an alpha (0.1.0-alpha.76, pinned by@composio/core) which will then enter the production image. Exact text for all four is in the review ledger.The guarantee, and how it is held
The branch's central claim is that three previously independent derivations — which protocol dials a row, whose credential it spends, whose name the audit trail records — plus a fourth nobody had noticed (which app at the broker a row is) are now resolved once, in
access.ts, and read as fields everywhere else.Each of these is gated by a test that fails when the guarantee fails, verified by mutating the source and watching the test die rather than by the suite being green:
__versionin the model's own arguments cannot stand in for a recorded one, and cannot choose which revision of an action runs.Behaviour changes outside Composio
Two, both small and both corrections:
An MCP server's declared effect is now believed. The MCP specification defines
annotations.destructiveHint, servers publish it, and the listing code was dropping it — so a tool its own vendor called destructive classified as a read wherever a hand-written write list omitted it. That hint is now read.readOnlyHintis deliberately not read: the SDK's own types warn against making tool decisions from annotations sent by untrusted servers, and honouring it would let anyone who can add a server by URL declare everything read-only and be believed. Drive is unaffected by construction — it never enters that code path. For Notion the only cell that changes is a destructive-declared tool absent from the reviewed list; nothing can move toward read.A failed query no longer carries its statement onward. Several call sites copied a database error's message into
last_errorand into the audit payload, which meant a failed query put its SQL and parameters in both. Deployment faults are now recognised by shape and raised rather than relayed.Migration
0029_composio— acomposio_connectionstable and three columns onmcp_tools(effect,destructive,version).It was
0028until this rebase; main had already taken that number. The snapshot is regenerated rather than renamed, because a rename alone would have left the newest snapshot describing a database missing the two columns main's own0028adds — which would make the next schema generation emit commands to drop them. That was reproduced before being fixed, and the drift probe is clean now.composio_connections.user_idhas no foreign key, by design. The row must outlive the person so offboarding can find it; that is the whole reason the table exists rather than reusing the existing credential join. Offboarding now deletes those rows and audits each one — note it shuts the gate this deployment owns and does not revoke at Composio, which thevendorRevoked: falsefield records honestly.Review
Two full unbiased review rounds, then a targeted fix cycle.
Round one produced thirty mandatory findings, all fixed. Round two — the byte-identical confirmation round — produced about twenty-five, roughly half of them gaps in round one's own fixes, and the correctness half of those is fixed here. Its most consequential finding is worth naming, because it is the kind only a second round catches: the first round fixed a data-loss bug where an empty listing wiped every tool row, and a later fix made the transport throw instead of returning empty. Both were correct. Together they routed the first round's tests around the guard, so it could be deleted outright with the suite still green. It is genuinely gated now.
Two recurring causes account for most of the rest, and both are worth knowing when reading this diff: a comment asserting something the code or the dependency does not do, with code then written to match the comment; and a test that passes regardless of the behaviour it names. One false sentence in an interface's own documentation produced both a real bug and the two test stubs that could not catch it.
The remaining test-quality tail is deliberately deferred and listed in full in the review ledger — assertions comparing a value to itself, loops that pass vacuously on an empty set, and pre-existing debt in the files this branch happens to touch.
Known and deliberately not fixed here
initiator_kind: "person"besideactor: "unattributed". Not a contradiction: one field says what set the run in motion, the other whose account it reached. Recording it asdeploymentwould assert the call went out on the deployment's credential when it never went out at all. Main'sDEPLOYMENT_INITIATORdocumentation claims that case and nothing sends it there, so the overlap is in the prose; narrowing that sentence is not this branch's call.Verification
bun test server/tests— 2071 pass, 4 skip, 0 fail across 136 files. The 4 skips are the live vendor suite, which needs an API key. Test count accounts exactly: 1841 at the merge base, +83 from main, +151 from this branch.typecheck,format:check,lintclean.drizzle-kit checkclean; the CI drift probe reports no unwritten migration.0028on a scratch database, landing both sets of columns.🤖 Generated with Claude Code
https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x